<?php
/**
 * Contains the Log_XML class.
 *
 * PHP version 5
 *
 * @category Logging
 * @package  Log
 * @author   Michael "Vladimyr" Gower <MichaelPeterGower@gmail.com>
 * @license  http://www.apache.org/licenses/LICENSE-2.0  Apache License 2.0
 * @link     http://pear.php.net/pepr/pepr-proposal-show.php?id=487
 */


/**
 * Include the parent's parent's class.
 */
require_once 'Log.php';

/**
 * Include our parent class.
 */
require_once 'Log/file.php';


/**
 * Include Log_XML_Exception().
 */
require_once 'XML/Exception.php';

/**
 * The Log_XML class is an abstract implementation of the Log abstract
 * class that logs messages to an XML file.  See the Log_XML_Xtj class
 * or the Log_XML_Simple class for a concrete implimentation of Log_XML.
 *
 * If you want to delete the log file, be sure to use the autodelete option.
 * If the log is deleted while in use, it will simply be rewritten on linux.
 * In Windows you must call $this->close() before unlink().
 *
 * Note: Assumes that both '/' and '\' are directory separators in quoted
 * literal strings, so stay away from 'nix filenames like 'my\file'.
 *
 * Based upon Log_file by Jon Parise <jon@php.net> and
 * Roman Neuhauser <neuhauser@bellavista.cz>.
 *
 * @category Logging
 * @package  Log
 * @author   Michael Peter "Vladimyr" Gower <designatevoid@gmail.com>
 * @license  http://www.apache.org/licenses/LICENSE-2.0  Apache License 2.0
 * @link     http://pear.php.net/pepr/pepr-proposal-show.php?id=487
 * @since    Log_XML 1.0
 * @todo do exceptions for error handling for v2.
 * @todo make fully E_STRICT compatable by using Log2 (when Log2 developed)
 *
 * @example ../docs/examples/index.php    Using the xml handler.
 */
class Log_File_XML extends Log_file
{
    /**
     * $conf headerFactory - a closure that returns an XML String
     *
     * Precondition: $conf[lineformat] is XML and $conf[headerFactory]() returns XML.
     *
     * Postcondition: If directories need to be created to open the log,
     *     they'll be created.
     *
     * For $conf we support the following parameters (+ Log_file's):
     * Parameter     - type       - default - Description
     * autoflush     - boolean    - true    - Should we flush after every logging
     *     statement?  This is great for getting logs of crashes, but may incur a
     *     performance hit.
     * autodelete    - boolean    - false   - Should the log file be deleted on
     *     object destruction?
     * headerFactory - closure    - blank   - Should be a closure that when evaluated
     *     with no arguments will return a XML String that will be the header.
     * lineformat    - String     - XML     - This is log Log_file's lineformat, but must be XML.
     *
     * @param string $filename The filename for the logfile.
     *  This can be absolute, relative, or null (a default will be provided).
     * @param string $ident    @See Log_file#Log_file 
     * @param array  $conf     The configuration array.
     * @param int    $level    The debug level.
     *
     * @See Documentation for Log_file.
     * @throws Log_file_XML_Exception
     * @return void
     */
    public function __construct($filename = Log_XML::DEFAULT_FILENAME,
        $ident    = '',
        $conf     = array(),
        $level= PEAR_LOG_DEBUG
    ) {
        if (!isset($conf['lineFormat'])) {
            $conf['lineFormat']="<message>%{message}</message>";
        }

        if (!$this->validFilename($filename)) {
            throw new Log_file_XML_Exception("Bad filename [$filename]");
        }

        parent::Log_file($filename, $ident, $conf, $level);

        $this->eolLength = strlen($this->_eol);

        if (isset($conf['autoflush'])) {
            $this->autoflush = $conf['autoflush'];
        }

        if (isset($conf['autodelete'])) {
            $this->autodelete = $conf['autodelete'];
        }

        if (isset($conf['headerFactory'])) {
            $this->headerFactory = $conf['headerFactory'];
        } else {
            $this->headerFactory = function()
            {
                return '';
            };
        }
    }

    /**
     * The destructor, flushes and closes the log as needed.
     *
     * @throws Log_File_XML_Exception
     * @return void
     */
    public function __destroy()
    {
        $this->flush();
        if (!$this->close()) {
            throw new Log_File_XML_Exception();
        }
        parent::_Log_file(); // parent's PHP4 destructor
    }

    /**
     * This will open the log, done automatically by $this->log().
     * This will also put in the initial skeliton of the log file
     * on the first call.
     *
     * @return boolean True on success, false on failure.
     */
    public function open()
    {
        if ($this->_opened) {
            return true;
        }
        $isFresh = !is_file($this->_filename) || !$this->_append;
        $isRealAppend = is_file($this->_filename) && $this->_append;
        $openRet = parent::open();
        if (!$openRet) {
            return false;
        }

        // if appending we don't want to open the handle in a or a+ mode
        // because that doesn't let us use fseek as needed
        // so close and reopen in w mode
        if ($this->_append) {
            $this->_opened = false;
            if (!fclose($this->_fp)) {
                return false;
            }
            if (($this->_fp = fopen($this->_filename, 'r+')) === false) {
                return false;
            }
            $this->_opened = true;
        }

        if ($isFresh) {
            // don't print header if appending
            if (!$this->printHeader()) {
                return false;
            }
            return $this->printFooter();
        } else if ($isRealAppend) {
            return $this->setFileCursorToJustBeforeFooter();
        } else {
            return true;
        }
    }

    /**
     * This closes the log.  It may be necessary to close the log
     * manually if you want to delete the log file.  Close is automatically
     * done on object destruction.
     *
     * @throws Log_File_XML_Exception
     * @return boolean True on success, false on failure.
     */
    public function close()
    {
        if (!$this->_opened) {
            return parent::close();
        }
        if ($this->_opened && !$this->flush()) {
            throw new Log_File_XML_Exception("Could not flush on close");
        }
        if (!parent::close()) {
            throw new Log_File_XML_Exception("Could not close parent");
        }
        if ($this->autodelete && !unlink($this->_filename)) {
            $message = "Could not delete log file [$this->_filename].";
            throw new Log_File_XML_Exception($message);
        }
        return true;
    }

    /**
     * Logs $message to the XML file.  The message is also passed along
     * to any Log_observer instances that are observing this Log.
     *
     * @param mixed  $message    String or object containing the message to log.
     * @param string $debugLevel The priority of the message.  Valid
     *                  values are: PEAR_LOG_EMERG, PEAR_LOG_ALERT,
     *                  PEAR_LOG_CRIT, PEAR_LOG_ERR, PEAR_LOG_WARNING,
     *                  PEAR_LOG_NOTICE, PEAR_LOG_INFO, and PEAR_LOG_DEBUG.
     *
     * @throws Log_File_XML_Exception
     * @return boolean  True Hon success or false on failure.
     */
    public function log($message = "", $debugLevel = null)
    {
        $bool_logReturn = parent::log($message, $debugLevel);
        if (!$bool_logReturn) {
            throw new Log_File_XML_Exception("Couldn't write log");
            return false;
        }
        if (!$this->printFooter()) {
            throw new Log_File_XML_Exception("Couldn't write footer");
            return false;
        }
        return true;
    }

    /**
     * This prints the header, the header may be blank.
     *
     * @throws Log_File_XML_Exception
     * @return boolean True on success, exception on failure.
     */
    protected function printHeader()
    {
        $headerFactory = $this->headerFactory;
        $header = $headerFactory();
        if ($header != "") {
            $header = "\n" . $header;
        }
        return $this->printLine($this->header . "$header");
    }

    /**
     * Also fseeks the handle back to just before the footer,
     * so that the footer will be overwritten. 
     *
     * @throws Log_File_XML_Exception
     * @return boolean Returns true on success, false on failure.
     */
    protected function printFooter()
    {
        $this->printLine($this->footer);
        $this->setFileCursorToJustBeforeFooter();
        return true;
    }

    /**
     * Moves the file cursor so the next fwrite will overwrite the last closing
     * tag of the log.
     *
     * @throws Log_File_XML_Exception
     * @return boolean
     */
    protected function setFileCursorToJustBeforeFooter()
    {
        $fseek_error = -1;
        $offset = -strlen($this->footer) - $this->eolLength;
        if (fseek($this->_fp, $offset, SEEK_END) == $fseek_error) {
            $contents = file_get_contents($this->_filename);
            $message
                = "Couldn't fseek($this->_fp, $offset) for contents:\n$contents";
            throw new Log_File_XML_Exception($message);
        }
        return true;
    }

    /**
     * Prints a line (with the _eol) to the log file.
     *
     * @param String $line The string to be written.
     *
     * @throws Log_File_XML_Exception
     * @return boolean True on success, false on failure.
     */
    protected function printLine($line)
    {
        /* If locking is enabled, acquire an exclusive lock on the file. */
        if ($this->_locking && !flock($this->_fp, LOCK_EX)) {
            throw new Log_File_XML_Exception("Couldn't lock file");
        }

        /* Write the log line to the log file. */
        if (!fwrite($this->_fp, $line . $this->_eol)) {
            throw new Log_File_XML_Exception("Couldn't print line [$line]");
        }

        /* Unlock the file now that we're finished writing to it. */
        if ($this->_locking && !flock($this->_fp, LOCK_UN)) {
            throw new Log_File_XML_Exception("Couldn't unlock file");
        }
        return true;
    }

    /**
     * Produces a formatted log line based on a format string and a set of
     * variables representing the current log record and state.
     *
     * @param String $format    The format of the log messages, must be XML.
     * @param int    $timestamp The timestamp.
     * @param int    $priority  The priority, see $this->log() for details.
     * @param String $message   The message of the log.
     *
     * @access  protected Can't be explicitly declared
     *     because of backwards compatability.
     * @since   Log 1.9.4
     * @return  string  Formatted log string.
     */
    function _format($format, $timestamp, $priority, $message)
    {
        /*
         * If the format string references any of the backtrace-driven
         * variables (%5 %6,%7,%8), generate the backtrace and fetch them.
         */
        if (preg_match('/%[5678]/', $format)) {
            list($file, $line, $func, $class) = $this->_getBacktraceVars(3);
        }

        /*
         * Build the formatted string.  We use the sprintf() function's
         * "argument swapping" capability to dynamically select and position
         * the variables which will ultimately appear in the log string.
         */
        return sprintf(
            $format,
            $timestamp,
            $this->_ident,
            $this->priorityToString($priority),
            $message,
            isset($file) ? $file : '',
            isset($line) ? $line : '',
            isset($func) ? $func : '',
            isset($class) ? $class : ''
        );
    }

    /**
     * Checks if a given string is a valid filename.
     *
     * @param string $filename the filename to validate
     *
     * @return boolean true if valid, false if not
     *
     */
    public function validFilename($filename)
    {
        if ($this->isWindows()) {
            // if has invalid filename, return false
            $regexp = '/PRN|CON|AUX|CLOCK\$|NUL|COM\d|LPT\d/i';
            if (preg_match($regexp, $filename)) {
                return false;
            }

            $parts = preg_split("/[\\\\\/]/", $filename);

            // if a drive letter, approve (don't check it later)
            if (preg_match('/[A-Za-z]:/', $parts[0])) {
                $parts[0] = null;
            }

            // checks for invalid printable charactors or control charactors
            foreach ($parts as $part) {
                if (preg_match('|[\^\|\?\*<\":>/\a\b\e\f\n\r\t]|', $part)) {
                    return false;
                }
            }
            return true;
        } else { // unix always passes
            return true;
        }
    }

    /**
     * Returns if this is a windows system or not.
     *
     * @return boolean True if PHP's OS is Windows or not.
     */
    protected function isWindows()
    {
        if (!isset($this->isWindows) && substr(php_uname(), 0, 7) == 'Windows') {
            $this->isWindows = true;
        } else if (!isset($this->isWindows)) {
            $this->isWindows = false;
        }
        return $this->isWindows;
    }

    protected function documentTheseVariables ()
    {
        // See Below
    }

    protected $isWindows;

    protected $logName = "Log";
    protected $headerName = "LogHeader";
    protected $messageName = "LogMessage";
    protected $header = "<Log>";
    protected $footer = "</Log>";
    protected $headerFactory = null;

    /**
     * Determines weather flush is called implicitly with every log() call or
     * if flush must be called explicitly.
     *
     * @var boolean
     */
    protected $autoflush = true;

    protected $autodelete = false;
}
